Decodificación restringida para salidas estructuradas en LLM

Sistema de tuberías industriales con flujos controlados representando decodificación restringida

Pedirle a un LLM que devuelva JSON válido puede fallar: el modelo inventa llaves cerradas, omite comas, añade comentarios. Constrained decoding (decodificación restringida) resuelve este problema cambiando cómo se generan tokens: en cada paso, solo se permiten tokens compatibles con la gramática deseada. El resultado: garantía matemática de que la salida cumple el formato.

Outlines, Guidance y jsonformer son las librerías principales. Este artículo cubre cómo funcionan, cuándo superan al json_mode de OpenAI, y cómo integrarlas.

El problema

Prompt: “Responde con JSON: {name, age}”.

El modelo puede:

  • Olvidar una coma.
  • Añadir ```json al inicio.
  • Añadir un “aquí tienes:” antes.
  • Omitir llave final.

Tras muchos prompts cuidadosos y pocos éxitos 100% fiables, buscas robustez real.

Cómo funciona constrained decoding

En cada paso de generación:

  1. El LLM produce distribución probabilística sobre vocabulario (~50k tokens).
  2. Máscara basada en gramática: tokens no-válidos obtienen probabilidad 0.
  3. Sample o argmax solo sobre tokens válidos.

Resultado: la salida respeta gramática perfectamente.

Se aplica con JSON Schema, regex, o gramáticas context-free (CFG).

Python library que funciona con muchos modelos (HF Transformers, llama.cpp, vLLM):

from outlines import models, generate

model = models.transformers("meta-llama/Llama-3-8B-Instruct")

# JSON con Pydantic
from pydantic import BaseModel

class User(BaseModel):
    name: str
    age: int

generator = generate.json(model, User)
user = generator("Extract user: Ana has 25 years old")
print(user)  # User(name='Ana', age=25)

Soporta:

  • Pydantic models para estructura tipada.
  • JSON Schema directo.
  • Regex para formatos específicos.
  • Gramáticas CFG para DSLs custom.

Guidance: más general

Guidance de Microsoft permite templates más complejos:

import guidance
from guidance import models, gen, select

llama = models.LlamaCpp("path/to/model.gguf")

lm = llama + "El color favorito es " + select(['rojo', 'azul', 'verde']) + "."

Permite mezclar texto fijo con regiones generadas bajo restricciones, ideal para prompts complejos.

jsonformer: simple y enfocado

Solo para JSON, pero muy simple:

from jsonformer import Jsonformer

schema = {
    "type": "object",
    "properties": {
        "name": {"type": "string"},
        "age": {"type": "integer"},
    }
}

jsonformer = Jsonformer(model, tokenizer, schema, prompt)
result = jsonformer()

Menos flexible pero más fácil de empezar.

vs OpenAI json_mode

OpenAI añadió response_format={"type": "json_object"} y luego Structured Outputs con JSON Schema. Comparación:

Aspecto Outlines local OpenAI Structured Outputs
Garantía gramática 100% 100% (schema)
Modelos Open (Llama, Mistral) GPT-4o+
Coste Local (GPU) API pricing
Privacidad Local OpenAI retention
Latencia Variable GPU ~depende API

Para servicios SaaS, OpenAI es OK. Para self-hosted, Outlines/Guidance.

Regex mode para formatos específicos

Outlines regex:

from outlines import generate

phone_gen = generate.regex(
    model,
    r"[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4}"
)
phone_gen("Call me at ")  # "555-123-4567"

Útil para:

  • Fechas en formato específico.
  • Números de serie.
  • Códigos (SKU, referencias).
  • Identifiers de tu dominio.

Gramáticas CFG

Para lenguajes estructurados propios. Ejemplo: SQL limitado, queries de tu DSL.

from outlines import generate

grammar = """
?start: expression
?expression: NUMBER | expression OP expression | "(" expression ")"
OP: "+" | "-" | "*" | "/"
%import common.NUMBER
"""
calc_gen = generate.cfg(model, grammar)

Útil cuando necesitas que el LLM produzca output procesable por otro sistema.

Trade-offs

Ventajas:

  • Garantía absoluta de formato.
  • Menos post-processing en aplicación.
  • Reduce hallucination de formato.
  • Usable con modelos pequeños (a veces mejor que prompt en grandes).

Desventajas:

  • Overhead computacional: cada token necesita masking. 10-30% más lento.
  • Integración: añadir a stack existente requiere trabajo.
  • No ayuda con calidad semántica: el JSON será válido pero el contenido puede ser malo.

Cuándo vale la pena

Casos donde constrained decoding gana claro:

  • Function calling / tool use: garantizar JSON de argumentos válido.
  • Extracción estructurada masiva: batch de miles de docs a DB.
  • Agentes con DSL propio: garantizar sintaxis válida.
  • Data generation: synthetic data con schema fijo.

Cuando no:

  • Chat conversacional: prompting es suficiente.
  • Casos donde modelo grande con prompts detallados ya funciona >99%.

Integración con vLLM y TGI

Estos runtimes soportan constrained decoding nativamente:

  • vLLM integra Outlines desde v0.4+.
  • TGI tiene GuidanceGrammar feature.
  • llama.cpp tiene grammar mode (--grammar).

Self-hosting con constrained decoding ya no requiere tu propia pipeline.

Ejemplos reales

Pattern que hemos visto:

  • Data extraction de facturas: schema JSON con items, totales.
  • Generación de SQL acotada a tu BD.
  • Agent tool selection: {"tool": "search", "args": {...}}.
  • Classification estructurada: {"category": "X", "confidence": 0.85}.

Conclusión

Constrained decoding es una herramienta subutilizada en el ecosistema LLM. Para cualquier caso donde necesites output válido garantizado, vale la pena. Outlines es la opción más madura open-source; OpenAI Structured Outputs cubre SaaS. El overhead de 10-30% más lento es trade-off aceptable para la garantía de robustez. Adoptarlo reduce significativamente código de validación y retry en tu aplicación — mejor hacer que funcione en decoding que parchear post-hoc.

Síguenos en jacar.es para más sobre LLMs, structured outputs y decodificación avanzada.

Entradas relacionadas